Skip to content

Guard against nil attachments in processing form - #1539

Merged
jazairi merged 7 commits into
mainfrom
etd-710
Sep 18, 2026
Merged

jazairi merged 7 commits into
mainfrom
etd-710

Conversation

@jazairi

@jazairi jazairi commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Why these changes are being introduced:

ThesisController#deleted_file_list calls .blob on a potentially nil Active Storage attachment.
The processing form can trigger a race condition
when a background process deletes a file that is
still visible on the form. If a user attempts to
delete that file and the attachment no longer
exists, the app throws a 422 error.

Relevant ticket(s):

How this addresses that need:

This adds a guard clause to check for the presence of the attachment before calling .blob.

Side effects of this change:

None.

Developer

Accessibility
  • ANDI or WAVE has been run in accordance to our guide.
  • This PR contains no changes to the view layer.
  • New issues flagged by ANDI or WAVE have been resolved.
  • New issues flagged by ANDI or WAVE have been ticketed (link in the Pull Request details above).
  • No new accessibility issues have been flagged.
New ENV
  • All new ENV is documented in README.
  • All new ENV has been added to Heroku Pipeline, Staging and Prod.
  • ENV has not changed.
Approval beyond code review
  • UXWS/stakeholder approval has been confirmed.
  • UXWS/stakeholder review will be completed retroactively.
  • UXWS/stakeholder review is not needed.
Additional context needed to review

E.g., if the PR includes updated dependencies and/or data
migration, or how to confirm the feature is working.

Code Reviewer

Code
  • I have confirmed that the code works as intended.
  • Any CodeClimate issues have been fixed or confirmed as
    added technical debt.
Documentation
  • The commit message is clear and follows our guidelines
    (not just this pull request message).
  • The documentation has been updated or is unnecessary.
  • New dependencies are appropriate or there were no changes.
Testing
  • There are appropriate tests covering any new functionality.
  • No additional test coverage is required.

Why these changes are being introduced:

`ThesisController#deleted_file_list` calls `.blob`
on a potentially nil Active Storage attachment.
The processing form can trigger a race condition
when a background process deletes a file that is
still visible on the form. If a user attempts to
delete that file and the attachment no longer
exists, the app throws a 422 error.

Relevant ticket(s):

- [USE-710](https://mitlibraries.atlassian.net/browse/ETD-710)

How this addresses that need:

This adds a guard clause to check for the presence
of the attachment before calling `.blob`.

Side effects of this change:

None.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Add coverage verifying stale attachment submissions complete without a 422/error.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Guards thesis processing against deleted Active Storage attachments.

Changes:

  • Checks attachment existence before accessing its blob.
  • Skips stale attachment entries safely.
File summaries
File Description
app/controllers/thesis_controller.rb Adds a nil attachment guard in deleted_file_list.

The stale-attachment regression path lacks controller test coverage and should be tested.

Review details

Suppressed comments (1)

app/controllers/thesis_controller.rb:182

  • This guard only skips building the flash-message entry; the same stale id remains in thesis_params and is passed to thesis.update at line 124. Because Thesis enables accepts_nested_attributes_for :files_attachments, Rails can still raise ActiveRecord::RecordNotFound while applying _destroy for an attachment that disappeared, so the reported 422 race is not fully prevented. Remove or ignore missing attachment entries before the update (and add a regression test for the stale id).
      next unless attachment
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +181 to +182
attachment = ActiveStorage::Attachment.find_by(id: file['id'])
next unless attachment
@coveralls

coveralls commented Sep 17, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 98.3% (+0.01%) from 98.286% — etd-710 into main

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The update path remains vulnerable to stale attachment races and incomplete truthy-value handling.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

app/controllers/thesis_controller.rb:128

  • This filter only recognizes the HTML string value '1'. accepts_nested_attributes_for treats other truthy forms such as 1/true as _destroy, so a stale attachment submitted with one of those values is not removed here and thesis.update can still raise ActiveRecord::RecordNotFound. Use the same boolean casting Rails uses for nested attributes (or normalize the value) before checking the missing attachment.
        marked_for_delete = attrs['_destroy'] == '1'
        attachment_id = attrs['id']
        missing_attachment = attachment_id.present? && !ActiveStorage::Attachment.exists?(attachment_id)

test/controllers/thesis_controller_test.rb:1015

  • This regression test removes the row before the request, so the new exists? filter deletes the submitted attributes before deleted_file_list runs. As a result, the test still passes if the new find_by/next guard at lines 193-195 is removed and does not actually exercise the nil-attachment path it claims to cover. Please add an assertion or setup that specifically makes deleted_file_list receive a missing attachment (while separately covering the nested-update stale-row behavior).
    # Simulate the race condition: Delete the attachment from the database
    # (This could happen if another process deletes it between form open and submit)
    ActiveStorage::Attachment.find(attachment_id).delete

    # Attempt to update the thesis with the deleted attachment marked for deletion
    # This previously would crash with "undefined method 'blob' for nil:NilClass"
    patch "/thesis/#{thesis.id}/process",
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread app/controllers/thesis_controller.rb Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The retry logic still has a race window, and the regression test does not exercise the intended race path.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (2)

app/controllers/thesis_controller.rb:138

  • The retry still leaves the same race window open: after the second drop_stale_deleted_attachment_rows! check, another process can delete the attachment before thesis.update, and because retried_for_missing_attachment is already true, that RecordNotFound is re-raised and the request still returns the 422 this change is intended to prevent. Consider handling the stale row atomically during the update or retrying/filtering until the submitted attachment rows are stable (with a bounded retry policy).
    rescue ActiveRecord::RecordNotFound => e
      raise unless missing_deleted_attachment_race?(e) && !retried_for_missing_attachment

      retried_for_missing_attachment = true
      drop_stale_deleted_attachment_rows!
      retry

test/controllers/thesis_controller_test.rb:1011

  • This setup deletes the attachment before the request, so drop_stale_deleted_attachment_rows! removes the submitted row at lines 123–124 and deleted_file_list never receives a missing attachment. Consequently, the regression test would still pass if the new find_by guard were removed; please arrange the deletion between the stale-row check and deleted_file_list (or directly exercise that method) so the nil-attachment path is actually covered.
    # Simulate the race condition: Delete the attachment from the database
    # (This could happen if another process deletes it between form open and submit)
    ActiveStorage::Attachment.find(attachment_id).delete
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread app/controllers/thesis_controller.rb

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved test route failures and attachment-handling cases remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

app/controllers/thesis_controller.rb:199

  • The new next unless attachment branch is not exercised by either regression test: the first test removes the row before deleted_file_list runs, while the second removes it only during update after this method has already completed. Please add a deterministic test that deletes the attachment between the stale-row check and this lookup (or otherwise makes find_by return nil) so the actual nil-guard behavior is verified.
      attachment = ActiveStorage::Attachment.find_by(id: file['id'])
      next unless attachment

app/controllers/thesis_controller.rb:216

  • This stale-row filter only recognizes the string '1', but nested-attribute requests can carry _destroy as an integer (1 is already used by the existing controller tests). For a missing attachment with that value, the row is not pruned, the first update raises, and the retry prunes nothing and raises again, so the request still fails. Use Rails' boolean casting (or normalize the value) here, consistently with nested-attribute semantics.
    params[:thesis][:files_attachments_attributes].delete_if do |_k, attrs|
      marked_for_delete = attrs['_destroy'] == '1'
      attachment_id = attrs['id']
      missing_attachment = attachment_id.present? && !ActiveStorage::Attachment.exists?(attachment_id)

test/controllers/thesis_controller_test.rb:1106

  • This named route requires the thesis :id (see config/routes.rb:43), so calling thesis_process_path without an argument raises ActionController::UrlGenerationError while evaluating the assertion. Pass the thesis used by this test so the regression test can run.
    assert_redirected_to thesis_process_path
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread test/controllers/thesis_controller_test.rb Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Regression tests use incorrect attachment IDs and omit the required route parameter.

Review details

Suppressed comments (3)

test/controllers/thesis_controller_test.rb:1106

  • This route requires the thesis :id parameter (config/routes.rb:43), so calling thesis_process_path without an argument raises ActionController::UrlGenerationError while evaluating the assertion. Pass the thesis, as the preceding regression test does, so this test can reach its response assertions.
    assert_redirected_to thesis_process_path

test/controllers/thesis_controller_test.rb:1007

  • has_many_attached :files returns blobs, so thesis.files.first.id is the blob ID rather than the ActiveStorage::Attachment ID expected by files_attachments_attributes. The test can therefore delete a different attachment (or fail to find one), meaning it does not reliably exercise the stale-attachment path. Use thesis.files_attachments.first.id (or capture the attachment returned by the association) here.
    # Get the attachment ID to mark for deletion
    attachment_id = thesis.files.first.id

test/controllers/thesis_controller_test.rb:1059

  • The race test has the same blob/attachment ID mismatch: thesis.files.first.id is a blob ID, while the nested attributes and ActiveStorage::Attachment.find_by call require the thesis attachment ID. As a result, the wrapper may delete a transfer attachment or no record, so the asserted retry path is not deterministic. Capture thesis.files_attachments.first.id instead.
    attach_files_to_records(transfer, thesis)

    attachment_id = thesis.files.first.id
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Add deterministic test coverage for the nil-attachment failure path.

Review details

Suppressed comments (1)

app/controllers/thesis_controller.rb:200

  • The new nil guard is not exercised by either regression test: the first test deletes the row before drop_stale_deleted_attachment_rows! runs, while the second deletes it inside update, after deleted_file_list has already completed. Add a deterministic test that lets the initial stale-row check observe the attachment and then removes it immediately before deleted_file_list calls find_by, so this specific failure mode is covered.
      attachment = ActiveStorage::Attachment.find_by(id: file['id'])
      next unless attachment

  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@jazairi
jazairi temporarily deployed to thesis-submit-pr-1539 September 18, 2026 17:09 Inactive
@jazairi
jazairi requested a lite review from Copilot September 18, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Final human review is recommended for the attachment race-handling and retry logic.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@jazairi

jazairi commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Given how much LLM ping-pong was involved here, I agree with Copilot's last assessment. I'm kind of annoyed by the in-test monkey-patching, but I couldn't figure another way to test the conditions that Copilot flagged.

@JPrevost JPrevost self-assigned this Sep 18, 2026

@JPrevost JPrevost left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird and neat problem.

Thanks for cleaning it up.

begin
updated = thesis.update(thesis_params)
rescue ActiveRecord::RecordNotFound => e
raise unless missing_deleted_attachment_race?(e) && !retried_for_missing_attachment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this gives us one retry correct? This isn't a loop, it's just one failure, one retry, and a second failure is an exception I believe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I figured if the problem is persistent, it's likely caused by something else that we should know about.

@jazairi
jazairi merged commit f33fb1b into main Sep 18, 2026
3 checks passed
@jazairi
jazairi deleted the etd-710 branch September 18, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants